當我們在寫單元測試時,我們會需要作假外部依賴,例如:http、Dio 等呼叫 Web API 用的套件。相同的,當我們在寫 Widget 時,有時也會需要想辦法隔離一些外部套件,這個在之前的文章中有討論過,今天我們看看一些比較不一樣的例子。
url_launcher 在開發 Flutter 的時候也是滿常用到的,這是一個 Flutter 官方開發的套件,當我想用瀏覽器開啟連結時,我們就會用 url_launcher 來幫忙處理。除此之外,像是 email 或手機號碼,也能用 url_launcher 開啟相對應的應用程式來處理。
launchUrl(Uri.parse("https://www.google.com"));
launchUrl(Uri(scheme: 'mailto', path: 'paul@gmail.com'));
而用法也很簡單,只要定義好 Uri 直接當參數呼叫 launchUrl 這個靜態方法即可。
大多時候,我們有 Web 也有 Mobile App 時,我們就會把網站的規範說明放在 Web 中,然後 Mobile App 使用瀏覽器或 WebView 打開這個網址,避免 Mobile App 需要在處理一次,在這邊我們就用 url_launcher 處理吧。[範例程式]
class TermAndCondition extends StatelessWidget {
const TermAndCondition({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
launchUrl(Uri.parse("https://www.google.com"));
},
child: const Center(
child: Text(
'Terms & Conditions',
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
);
}
}
當我們開始測試時,寫完 pumpWidget,寫完 tap,最後當我們想驗證頁面有沒有被打開時就會卡住。我們可能會想用 Mock 的方式來驗證,但是因為 launchUrl 方法是第三方的套件,而且是使用全域方法,導致我們無法直接 Mock 它。
main() {
testWidgets("should open t&c page when click t&c", (tester) async {
await tester.pumpWidget(const MaterialApp(home: TermAndCondition()));
await tester.tap(find.text("Terms & Conditions"));
// 如何驗證
});
}
為了解決,我們可以使用最經典的方式,Extra And Override 來處理,實作一個 TestTermAndCondition 繼承 TermAndCondition,覆寫並且攔截參數,最後就可以在測試中使用。
class TestTermAndCondition extends TermAndCondition {
Uri? uri;
@override
void openUrl(Uri uri) {
this.uri = uri;
}
}
可以看到在測試中我們就可以直接比較 testTermAndCondition 的 uri 是否正確。
main() {
testWidgets("should open t&c page when click t&c", (tester) async {
var testTermAndCondition = TestTermAndCondition();
await tester.pumpWidget(MaterialApp(home: testTermAndCondition));
await tester.tap(find.text("Terms & Conditions"));
expect(testTermAndCondition.uri, Uri.parse("https://www.google.com"));
});
}
這個解法雖然有用,比較容易用在 StatelessWidget,而比較難在 StatefulWidget 中,因為 StatefulWidget 包含了兩個類別 Widget 類別與他相對應 State 類別。
class TermAndCondition extends StatefulWidget {
const TermAndCondition({super.key});
@override
State<TermAndCondition> createState() => TermAndConditionState();
}
class TermAndConditionState extends State<TermAndCondition> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
openUri(Uri.parse("https://www.google.com"));
},
child: const Center(
child: Text(
'Terms & Conditions',
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
),
),
);
}
void openUri(Uri uri) {
launchUrl(uri);
}
}
這使得我們除了覆寫 TermAndCondition 之外,還得公開 TermAndConditionState 並覆寫。
class TestTermAndCondition extends TermAndCondition {
@override
State<TermAndCondition> createState() => TestTermAndConditionState();
}
class TestTermAndConditionState extends TermAndConditionState {
Uri? uri;
@override
void openUri(Uri uri) {
this.uri = uri;
}
}
然後我們才能測試,但實際上要驗證的時候也是頗為麻煩,我們得找到 Element,接著從 Element 中找到存在 Element 中的 State,最後我們才能驗證 uri 是否預期。測試流程比起 StatelessWidget 來說,要複雜不只一倍。
main() {
testWidgets("should open t&c page when click t&c", (tester) async {
var testTermAndCondition = TestTermAndCondition();
await tester.pumpWidget(MaterialApp(home: testTermAndCondition));
await tester.tap(find.text("Terms & Conditions"));
var element = tester.element<StatefulElement>(find.byWidget(testTermAndCondition)) ;
var state = element.state as TestTermAndConditionState;
expect(state.uri, Uri.parse("https://www.google.com"));
});
}
Extra And Override 看似方便,其實仔細想想,就會發現並不是最好的方式。因為我們可能會在許多 Widget 都使用 launchUrl,也意味著我們可能需要在每個需要的 Widget 都做一次重複的事情,這顯然提高了測試成本。
在這個問題上,當然我們也可以選擇避免直接 url_launcher,製作一個 UriRepository 封裝 launchUrl 的操作。然後使用依賴注入框架取得 UriRepository 使用。
class TermAndCondition extends StatelessWidget {
const TermAndCondition({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
getIt<UriRepository>().open(Uri.parse("https://www.google.com"));
},
child: ...,
);
}
}
class UriRepository {
void open(Uri uri) => launchUrl(uri);
}
這邊我們就不多做示範,相信看到這邊的觀眾應該知道該怎麼處理了。但是今天我們還想提另外一個作法,也就是使用 url_launcher 提供的 API 來注入 Mock 的 UrlLauncher,讓我們繼續看下去。
首先我們得在 dev 的相依中加入相關套件 plugin_platform_interface 與 url_launcher_platform_interface。
flutter pub add --dev plugin_platform_interface
flutter pub add --dev url_launcher_platform_interface
為什麼需要這兩個套件呢?因為我們打算直接在測試中把假的 MockUrlLauncher 塞入 UrlLauncherPlatform 的 instance 單例變數,這樣就能讓測試執行到 UrlLauncher 時,使用的是 MockUrlLauncher。
UrlLauncherPlatform.instance = MockUrlLauncher();
回到最一開始的問題,需要這兩個套件的原因是 MockUrlLauncher 需要實作來自 url_launcher_platform_interface 中的 UrlLauncherPlatform,和 with 來自 plugin_platform_interface 的 MockPlatformInterfaceMixin。
class MockUrlLauncher
extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
}
為什麼需要 UrlLauncherPlatform 很好理解,因為這個就是 UrlLauncher 的介面,實作了這個介面,我們才能把 MockUrlLauncher 塞到 UrlLauncherPlatform 的 instance 中,那 MockPlatformInterfaceMixin 呢?
如果們看 MockPlatformInterfaceMixin 的原始碼會發現他什麼實作都沒有。簡單來說,MockPlatformInterfaceMixin 只是拿來標示這個類別是測試用的。
@visibleForTesting
abstract class MockPlatformInterfaceMixin implements PlatformInterface {}
當使用 MockUrlLauncher 塞到 UrlLauncherPlatform 的 instance 時,UrlLauncherPlatform 會做一些驗證,但是當塞進去的類別是 MockPlatformInterfaceMixin 時,UrlLauncherPlatform 就會跳過這些驗證,我們也就能避免測試因為這些驗證不通過而紅燈。
最後我們就得到一個 MockUrlLauncher,並在其中監聽 lauchUrl 的呼叫狀況,就能輕鬆測試傳進來的 url 是否有正確了。
class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {
String? url;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
this.url = url;
return true;
}
}
最後我們只要建立 MockUrlLauncher 並它它塞到 UrlLauncherPlatform.instance 中,就能成功測試 launchUrl 的結果是否正確。
main() {
setUp(()=> registerFallbackValue(const LaunchOptions()));
testWidgets("should open t&c page when click t&c", (tester) async {
var mockUrlLauncher = MockUrlLauncher();
UrlLauncherPlatform.instance = mockUrlLauncher;
when(() => mockUrlLauncher.launchUrl(any(), any())).thenAnswer((invocation) async => true);
await tester.pumpWidget(const MaterialApp(home: TermAndCondition()));
await tester.tap(find.text("Terms & Conditions"));
verify(() => mockUrlLauncher.launchUrl("https://www.google.com", any()));
});
}
class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {}
與前面的測試方法相比,使用 MockUrlLauncher 除了讓測試變得十分簡單,不同 Widget 之間如果有相同的測試需求,也能重複使用 MockUrlLauncher,也不需要依賴注入框架輔助,讓測試更輕鬆。
P.S 由於這邊需要自己製作 Mock 比較方便,所以我們使用 mocktail 來測試,語法上與 mocktio 有一些小差異,但應該不至於影響閱讀測試。
若以 Clean Architecture 的設計分層來說,Widget 是處於為外層的部分,也自然與框架或外部依賴最深,測試也會變得不好測。大多時候我們可以用 Extra And Override 這個萬金油來解決,但同時也必須想想,套件開發者是不是已經為我們準備好可測試的攔截點了,善用這些別人已經造好的輪子,可以除了可以省去寫大量重複程式碼的時間,也可以簡化測試,提升測試的可讀性。